Skip to content

Preserve the root when removeDotSegments pops past it (RFC 3986 §5.2.4) - #190

Open
gaoflow wants to merge 1 commit into
thephpleague:masterfrom
gaoflow:fix/remove-dot-segments-absolute-root
Open

Preserve the root when removeDotSegments pops past it (RFC 3986 §5.2.4)#190
gaoflow wants to merge 1 commit into
thephpleague:masterfrom
gaoflow:fix/remove-dot-segments-absolute-root

Conversation

@gaoflow

@gaoflow gaoflow commented Jul 28, 2026

Copy link
Copy Markdown

Summary

UriString::removeDotSegments() drops the leading slash when a .. segment pops past the root, turning an absolute path into a rootless (relative) one. Per RFC 3986 §5.2.4, popping at the root operates on the (empty) output buffer, so the leading / is preserved.

UriString::removeDotSegments('/../a');           // "a"    — RFC: "/a"
UriString::removeDotSegments('/a/b/../../../c');  // "c"    — RFC: "/c"
UriString::removeDotSegments('/../../');          // ""     — RFC: "/"

The explode('/') + array_reduce shortcut represents the leading / as an empty first segment; when .. triggers array_pop, that empty marker is removed along with a real segment, so the root is lost.

Because foo:/../a and foo:a identify different resources (absolute vs. rootless path, RFC 3986 §3.3), this is also visible through the public helpers:

UriString::normalize('foo:/../a');                    // "foo:a"        — RFC: "foo:/a"
UriString::normalize('file:/../etc/passwd');          // "file:etc/passwd" — RFC: "file:/etc/passwd"
Path::new('/../a')->withoutDotSegments()->toString(); // "a"            — RFC: "/a"

Fix

BaseUri::removeDotSegments() already carries this exact guard (with the comment "added because some PSR-7 implementations do not respect RFC3986"); it was never back-ported to the canonical UriString implementation. This restores the leading slash only when the input path was absolute:

if (str_starts_with($path, '/') && !str_starts_with($newPath, '/')) {
    return '/'.$newPath;
}

Scope / no regression

  • resolve() is unchanged: it already re-prepends / for URIs with an authority, so resolution was correct and stays byte-identical. The full RFC 3986 §5.4 resolution corpus (resolveProvider, extraResolutionProvider) still passes.
  • The guard fires only when an absolute path would otherwise become rootless. Relative paths stay relative (a/../b -> b) and non-overshoot paths are untouched (/a/b/../c -> /a/c), so no existing behaviour changes.
  • New data-provider test covers the overshoot cases, the unchanged absolute/relative cases, and normalize() on a scheme-only URI.

UriString::removeDotSegments() used the explode()/array_reduce() shortcut,
where array_pop() also drops the leading empty segment once ".." pops past
the root. So "/../a" returned "a", turning an absolute path into a rootless
one, contrary to RFC 3986 section 5.2.4 which keeps the root ("/a").

The same deviation surfaced through the public helpers Path::withoutDotSegments()
and UriString::normalize() on a scheme-only URI ("foo:/../a" gave "foo:a").
resolve() was already correct, since it re-prepends the slash for URIs with an
authority. BaseUri::removeDotSegments already carried this guard; back-port it
to the canonical implementation.
@nyamsprod

Copy link
Copy Markdown
Member

seems the tests are not passing anymore 🤔

@gaoflow

gaoflow commented Aug 1, 2026

Copy link
Copy Markdown
Author

Thanks — confirmed. The failures are in unchanged UriTemplate\\TemplateTest cases from the live uri-templates/uritemplate-test dev-master dependency (new grammar, multibyte-prefix, and literal-encoding cases), not either PR diff. I will keep an eye on the upstream compatibility fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants